every Tech Blog

株式会社エブリーのTech Blogです。

速くて安いAI「Jev」でQAを自動化してみた

はじめに

こんにちは!株式会社エブリーで1ヶ月間、iOS エンジニアとしてインターンをしていた木村風葉です。ヘルシカの iOS チームで、食事記録まわりの改善や無料会員向けの機能追加など、いくつかの機能開発に取り組みました。

この記事では、インターンの後半に取り組んだ「Jev」という新しいAIを使った検証ツールづくりについて書きます。Jevは、文章を生成する代わりに「選択肢のどれを選ぶか」を確率付きで返すAIです。これを使ってiOSシミュレータ上のアプリを自動で触り回らせ、不具合を探す仕組みを作りました。

なお、ヘルシカの開発チームでは、AI コーディングツールの Claude Code を日常的に使っています。この記事で Jev と比べている「Claude」は、基本的にこの Claude Code を指します。

Jev とは

Jev は、TypeSafe AI が開発した「System One Model」と呼ばれる AI モデルです。ChatGPT や Claude のような LLM(大規模言語モデル)は文章を生成しますが、Jev は文章を生成しません。状態(state)と、型の決まった質問(questions)を渡すと、それぞれの質問に対する答えを確率付きで返します。

docs.typesafe.ai

質問の型は次の3つです。

型 返ってくるもの 例
noul 真である確率(0〜1) 「エラーが表示されているか」→ 0.93
choice 選んだ選択肢と、全選択肢の確率 「次に押すボタンはどれか」→ 「続行(3/4)」
score 段階評価と、各段階の確率 「健康的な度合い(悪い/普通/良い)」

たとえば、1日の食事内容を渡して「栄養バランスが取れているか」「一番の課題は何か」を聞くと、次のような形で答えが返ってきます(イメージです)。

{
  "answers": {
    "is_balanced": { "noul": 0.12 },
    "main_issue": {
      "choice": "veg",
      "confidence": 0.81,
      "probabilities": { "carb": 0.11, "fat": 0.08, "veg": 0.81 }
    }
  }
}

主な特徴は次のとおりです。

  • 速い: 1回の判断が数百ミリ秒程度で返ってきます
  • 安い: 入力は 100万トークンあたり $0.042 で、出力は無料です(2026年10月時点)
  • 入力はテキストだけ: 画像は渡せません

今回は、社内で検証用に一時的に用意していただいた環境を使って試しました。

作ったもの:jev-qa

きっかけ

検証用の環境を用意していただいて最初に思いついたのは、アプリの動作確認です。オンボーディング(初回の登録の流れ)を毎回手で進めるのが大変だったので、「Jev に画面を判断させながら、高速に操作させられないか」と考えました。

ただ、オンボーディングは画面の順番が決まっています。決まった手順を毎回 AI に判断させるより、手順を書いたテストスクリプト(XCUITest など)のほうが速くて確実です。そこで、目的を次のように広げました。

オンボーディングをjevで通せるなら、その先の画面も触らせて、不具合探しにも使えるのでは?

手順が決まっていない状態でアプリ中を触り回るのであれば、毎回その場で判断が必要になります。ここなら、速くて安い Jev の出番があるのではないかと考えました。

構成

作ったツールは jev-qa と名付けました。(今思えばヘルシカに限定した名前の方が良かったかも...)

主な構成は次のとおりです。

  • AXe: iOS シミュレータの画面情報を読み取り、タップや入力を行う CLI ツール
  • jev_qa.py: 画面の情報を Jev に渡し、返ってきた判断をもとに操作する Python スクリプト
  • 設定ファイル(YAML): アプリ固有のルールや、検証したい操作の流れ(シナリオ)

github.com

1ステップの流れ

Jev には画像を渡せないので、スクリーンショットではなくアクセシビリティの情報(画面上のボタンや文字の情報をテキストにしたもの)を渡します。1ステップの流れは次のとおりです。

1ステップの流れ

Jev には、1回の呼び出しで次の質問をまとめて聞いています。

質問 型 答え
operation choice タップ/入力/下にスクロール/上にスクロール/戻る/待つ/完了/行き詰まり
target choice 操作する要素の番号
is_error_shown noul エラー表示が出ているか
goal_achieved noul ゴールを達成したか

ここでハマったのが、Jev は質問ごとに別々に判断していて、ほかの質問の選択肢を見ていないという点です。最初は要素の一覧を target の選択肢にだけ入れていたため、operation を判断するときに画面の中身が分かっていませんでした。そのため、要素の一覧は状態(state)にも入れるようにしました。

Jev の判断をそのまま使わない

動かしているうちに、Jev の判断をそのまま使うと困る場面がいくつも出てきました。たとえば、同じボタンを押し続ける、自信が無いのに「戻る」を選ぶ、といった動きです。そこで、ツール側で次のような補正をかけるようにしました。

  • 自信度が 0.7 未満の「戻る」は実行しない
  • 画面が変わらなかったのに同じ要素を押そうとしたら、次に確率が高い要素にする
  • 購入・削除・退会・サインインなどのボタンは押さない
  • 課金画面(paywall)が出たら、Jev に判断させずに左上の × を押す

また、Jev が「ゴールを達成した」と答えても、それだけでは完了にしないようにしました。自己申告だけで完了にすると、失敗しているのに完了扱いになることが連発したためです。代わりに、シナリオの段階ごとに「この画面にこの文字が出ていたら達成」という条件(done_when)を書き、ツールが確認します。

シナリオの例(イメージ)

name: 体重を記録する
start:
  relaunch: true   # アプリを再起動してから始める
  screen: home
inputs: ["60.5"]   # 入力欄に入れてよい値
stages:
  - goal: 体重の記録画面を開く
    done_when:
      screen: weight_record
    max_steps: 6
  - goal: 体重を 60.5 kg で保存する
    done_when:
      texts_all: ["60.5"]
      screen: home
    max_steps: 8

ゴールを段階に分けたのは、Jev が「この画面で次に何を押すか」を選ぶのは得意な一方、長い手順の中で「今どこにいて、何をすべきか」を判断するのは苦手だったためです。判断させる範囲を小さくするほど、動きが安定しました。

こうして、体重の記録、運動の記録、設定画面の行き来、日付の切り替えなど、いくつかのシナリオを通せるようになりました。

jev-qa が運動記録にウォーキング30分を追加している様子

探索モード:ゴールを決めずに触り回る

決まったフローの確認よりも Jev が活きそうなのが、ゴールを決めずにアプリ中を触り回る探索です。秒数を指定すると、まだ押していないボタンを優先して押し続けるモードを作りました。

python3 jev_qa.py --explore 240   # 240秒、ゴールを決めずにアプリ中を触る

固まった、アプリの外に出た、完全に行き詰まった、といった場合は、アプリを再起動して続けます。最初の4分間の探索では、83ステップで65種類のボタンを触りました。

気になる点を「スクショの名前」で記録する

探索の目的の1つは、不具合や、ユーザーにとってわかりにくい表示を見つけることです。ただ、Jev は文章を出せないので、「何がおかしいか」を説明させることはできません。そこで、気になる点があったら、スクリーンショットを ISSUE_<種類>_step<番号>.png という名前で残すことにしました。何かあったことさえ分かれば、あとから人や Claude が見て判断できます。

種類 判定する方法
エラー表示・わかりにくい表示 Jev(noul の確率が高いとき)
ボタンの読み上げ名が画像のファイル名になっている、仮の値(null など)が出ている スクリプト
固まった・アプリの外に出た・行き詰まった ツール

ここでいう 「わかりにくい表示」 は、Jev に「画面に、ユーザーにとってわかりにくい・不自然な表示があるか」を noul で聞き、その確率が高かったときに記録するものです。質問文には、説明のない数値や記号、意味の通じない文言、不自然な空欄などを例として書いています。

「123456 の次は 123457」を試せる探索

探索を何回かに分けて流すと、毎回同じところばかり触ってしまいます。そこで、画面と操作の記録を、実行をまたいで残すようにしました。

最初は「一度押したボタンは次から避ける」という単純な仕組みにしました。しかし、それでは不十分でした。たとえば、ボタン 1→2→3→4→5→6 と進んだ後、6 の代わりに 7 を押すルート(1→2→3→4→5→7)も試してほしいのに、1 が「押した」扱いになると、その先の分岐を試せなくなります。

そこで、画面と遷移をグラフ(画面A --[押したボタン]--> 画面B)として記録し、次のルールで選ぶようにしました。

分岐を残したまま「済み」にしない探索

  1. その画面に、まだ押していないボタンがあれば、それを押す
  2. 全部押し済みなら、押した先に未探索の分岐が一番近く残っているボタンをもう一度押す
  3. 先が全部探索済みなら、戻るかスクロールする

途中のボタンは、その先がすべて探索済みになるまで「済み」にしません。これで、1→2→3→4→5→6 を試した後に、1〜5 を通り直して 7 を試す動きになりました。

比較と評価

「Claude がやったほうが早くない?」問題

シナリオが通るようになってきた一方で、運動の記録(ウォーキング30分を追加する)シナリオを動かしたとき、1回に47秒かかりました。オンボーディングのときより、1つ1つの操作が明らかに遅く感じました。

試しに、Jev を使わずに Claude に同じシナリオの操作をさせてみたところ、19.5秒で終わりました。正直、「これなら Jev を使う意味なくない?」と思いました。

まず、どこに時間がかかっているのかを測りました。

Jev の判断 操作 画面の変化待ち
1ステップの平均 0.33秒 1.16秒 2.01秒

Jev の判断は1ステップあたり0.3秒ほどで、全体の1割程度でした。時間の大半は、画面の読み取りと操作、つまり Jev 以外の部分でかかっていました。

原因を調べると、AXe がタップのたびに画面の向きを確認していて約0.6秒かかることや、画面の読み取りが要素の数に比例して遅くなることが分かりました。入力の手順を整理したり、操作後の固定の待ち時間をやめて画面の変化を見て進むようにしたりして、運動の記録のシナリオは 47.1秒 → 約33秒 になりました。

また、Claude の19.5秒は、何度も動かして画面の流れやボタンの名前を把握したうえで、あらかじめ完璧な手順を書いたスクリプトを実行した時間でした。初めて見る画面で同じことはできません。Claude が1手ずつ画面を見て判断する場合は、1手ごとに数秒以上かかり、費用も Jev より桁違いに高額になります。

整理すると、次のようになります。

やり方 速さ 初めて見る画面 画面が変わったとき 費用
決め打ちのスクリプト ◎ × 事前に作る必要がある × 壊れる ほぼ0
Jev が1手ずつ判断 ○ ○ ○ ほぼ0
Claude が1手ずつ判断 △ ◎ ◎ 高い

ここから、決まった操作フローの確認なら決め打ちのスクリプトのほうがよく、Jev は手順が決まっていない「探索」に向いている、という方向性が見えてきました。

料金

Jev は入力が安く、出力が無料なので、料金はほとんどかかりませんでした(1ドル150円で計算)。

実行 入力トークン 料金
1ステップ 約1,500 約0.01円
シナリオ1本(4〜9ステップ) 7,500〜17,000 約0.05〜0.1円
探索 4分 12.5万〜14.5万 約0.8〜0.9円

4分間アプリを触り回って1円以内です。シナリオ6本を1日10回流しても、月に200円もかかりません。(もちろん1日10回回す必要はないですが...)

Claude と Jev の使い分け

最後に、Jev を使わずに Claude だけで2分間の探索をさせ、Jev と比べてみました。

Claude が直接操作 Jev による探索
1操作あたり 約7秒 約2〜3秒
2分で触れた数 15操作 40〜50操作
料金 Claude の利用量 約0.5円
見つけやすいもの 意味を伴う違和感(表示の順番、仕様と合っているか など) エラー表示、固まる、行き詰まる

この結果から、次のような使い分けがよいと考えています。

  • 決まった操作フローの確認: 決め打ちのスクリプト(速くて確実)
  • 広く速く触って、異常を見つける: Jev(速くて安い)
  • 見つかったものの意味を判断する、原因を調べる、シナリオを書く: Claude

Jev は Claude の代わりになるものではなく、大量に、速く、安く判断したい部分を受け持つ部品だと感じました。

見つかったもの

数日間の試行で、手で触っていたときには気づいていなかった不具合や、仕様を確認したほうがよさそうな点がいくつか見つかりました。

見つかったものは、大きく次の3種類でした。

  • 動作の不具合: アプリが応答しなくなる、エラーが表示されるといった、ユーザーの操作が止まってしまうもの
  • 表示の違和感: 画面の状況と文言が合っていないなど、仕様として意図どおりかを確認したいもの
  • アクセシビリティの問題: 画面の読み上げで、ボタンの役割が伝わりにくいもの

特に役に立ったのは、普段あまり通らない操作の組み合わせで起きるものを拾えたことです。探索モードは人が普段やらないようなパターンも網羅的に走査できるので、手での確認では通りにくい道筋を自然に通ってくれました。見つけたときにはスクリーンショットと操作の記録が残っているので、再現手順をすぐに確認でき、そのまま原因の調査につなげられました。

アクセシビリティの問題は、最初から探していたわけではなく、ツールを作っている途中で気づいたものです。このツールは画面を読むのにアクセシビリティの情報を使っていて、これは iPhone の読み上げ機能(VoiceOver)が読み上げる情報と同じです。つまり、ツールで読んだボタンの名前が画像のファイル名になっていれば、VoiceOver でもそのまま読み上げられているということです。ボタンに意味のある名前が付いていれば、Jev や XCUITest もボタンを見分けやすくなるので、テストのしやすさにもつながると感じました。

課題

失敗談:課金画面の購入ボタンを押しかけた

探索モードでは、ヒヤッとすることもありました。

探索中に課金画面(paywall)が開いたのですが、Webで表示される画面だったため中身の読み込みがツールの確認より遅れ、ツールが課金画面に気づけませんでした。ツールは後ろにあるホーム画面の情報をもとにボタンを押すため、その位置によっては課金画面の購入ボタンを押す可能性があります。 シミュレータはAppleアカウントにサインインしていなかったので実際に有料プランの購入を完了させることはありませんが、AIに操作させるときは「押してはいけないものを押す」前提で守りを固める必要がある、と実感しました。対策として、次の3つを入れました。

  • 押す直前に、押す位置の要素が今の画面の情報に含まれているかを確かめる。含まれていなければ押さない
  • サインイン画面が出たら、Jev に判断させず「キャンセル」だけを押す
  • 探索モードでは、課金画面の入口になるボタンを押さない

「わかりにくい表示」の判定が当てにならない

Jev による「わかりにくい表示」の判定は誤検知が多く、正直なところあまり役に立ちませんでした。一方で、エラー表示の判定や、スクリプトでの判定は役に立ちました。表示の意味まで踏み込んだ判断は、今のところ Claude や人が見たほうが確実です。ツールの実行をClaudeに持たせればツールの検知した「わかりにくい表示」が誤検知かどうかQA後に判定してくれるので、そのような実行方法の方が振り返りやすいと思います。

まとめ

Jev を使って、iOS シミュレータ上のアプリを自動で触り回る検証ツールを作りました。

取り組みを通じて、次のことを学びました。

  • AI に操作させるときの速さは、AI の判断以外で決まることが多い。推測せずに測ったら、Jev の判断は全体の1割程度でした
  • AI に判断させる範囲は小さくする。長いゴールを段階に分けると、動きが安定しました
  • AI の自己申告をそのまま信じない。完了の判定や、押してはいけないボタンは、ツール側のルールで決めます
  • 道具ごとに得意なことが違う。決め打ちのスクリプト・Jev・Claude を役割で使い分けるのがよいと考えています

今後は、Jev がアプリを操作し、失敗したときは Claude が原因を調べて設定を直す、という仕組みも作ってみたいです。その際は、テストを通すために Claude が合格の条件を緩めてしまわないよう、Claude に直させてよい範囲を決めておく必要があります。

エブリーのインターンでは、複数の機能開発だけでなく、新しい技術を試して開発のやり方そのものを改善する取り組みにも挑戦できました。実際にチームに入り、新機能の実装からリリースまでの一連の流れに関われたのは、とても貴重な機会でした。

社員の皆さんは明るく接しやすい方ばかりで、気軽にランチにも誘っていただきました。おかげで、楽しく充実した1ヶ月を過ごせました。大変お世話になりました。

新しい技術を試しながらプロダクトの開発に関わりたい方は、ぜひエブリーのインターンに応募してみてください!

※28卒インターンの募集は終了しました

参考資料

docs.typesafe.ai

thenewstack.io

github.com

Claude Code ぶん回して仕様駆動開発を実践してみた 〜 設計書3.6万行と6つの落とし穴 〜

Claude Code ぶん回して仕様駆動開発を実践してみた 〜 設計書3.6万行と6つの落とし穴 〜
Claude Code ぶん回して仕様駆動開発を実践してみた 〜 設計書3.6万行と6つの落とし穴 〜

 こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️(@every_hawk_eyes)です。本記事で紹介するプロジェクトでは、エンジニアリーダーとしてフロントエンド・バックエンド・インフラ・アプリ開発のすべてに関わりながら、プロダクトマネジメントも少し担っていました。

この記事のポイント

  • 2026 年 6〜8 月の 3 か月間、3〜7 名のチームで、ネットスーパーの Server API、AWS基盤、ピッキングアプリを、Claude Code を中心に新規開発
  • 設計書を Claude Code に書かせ、人間がレビューで Fix してから実装する、仕様駆動開発(SDD)に近い進め方をとった。1 つの機能だけで設計書・計画書は約 3.6 万行に達し、ボトルネックは人間のレビューへ
  • デザイナー不在の中、注釈付き既存画面スクショと Material Design 3 の方針だけでアプリ開発
  • AI の尤もらしい誤り、足し算の設計、膨らむドキュメント、設計書と実装のズレ、コンテキストの断絶、Git 事故という 6 つの落とし穴を、解決策と併せて紹介

問題提起

  • AI に任せてもそれなりのものが作れる時代でも、平準化されないエンジニアの価値とは、
    • AI と質疑を重ねて設計を詰め切る力と、チーム内議論での発言力・決断力ではないか
  • AI の尤もらしい誤りを疑うためのドメイン知識とシステム全体の理解を得るには、
    • 誰が読んでも共通のドメイン知識を持てるドキュメント整備が肝要なのではないか

背景

 弊社では Claude を非エンジニアも含めた全社に展開しており、我々のチームでも今年 3 月に Cursor から Claude Code へ移行しました。

 2026 年 6〜8 月には、新しい小売チェーン向けにネットスーパーを立ち上げる新規開発がありました。Server API、AWS インフラ、店舗の作業者が使う Android のピッキングアプリまでを、最小 3 名、いちばん多い時期でも 7 名(1 か月間のみ)で作る必要があり、アプリのデザイナーもいません。そこで、設計から実装・テスト・障害調査まで、Claude Code を「ぶん回す」前提で進めました。

 本記事では、うまくいったやり方に加えて、現場で実際に起きた問題点と、それに対する解決策についてを紹介します。

前提

システムの全体像

システムの全体像。既存の小売は、別のクラウドサービスの VM で動くネットスーパー本体(PHP)と DB を使う。新規小売は、AWS に移したネットスーパー本体(PHP / ECS Fargate)と RDS を使う。両方の本体から Go の Server API を呼び出し、Server API は両方の DB を読み書きする。ネットスーパーから独立したピッキングシステムは、Server API から注文データを受け取って Aurora DSQL に保存し、Server API へは EventBridge のカスタムバスと API Destination で非同期に連携する
システムの全体像。既存の小売は、別のクラウドサービスの VM で動くネットスーパー本体(PHP)と DB を使う。新規小売は、AWS に移したネットスーパー本体(PHP / ECS Fargate)と RDS を使う。両方の本体から Go の Server API を呼び出し、Server API は両方の DB を読み書きする。ネットスーパーから独立したピッキングシステムは、Server API から注文データを受け取って Aurora DSQL に保存し、Server API へは EventBridge のカスタムバスと API Destination で非同期に連携する

 ネットスーパー本体は、別のクラウドサービスの VM で長年動いてきた PHP のシステムです。本体は極力改修しない方針で、新しい処理は Go の Server API に寄せています。今回は、主に次の 2 つの対応が必要でした。

  • インフラを AWS に 1 本化するための、ネットスーパー本体の AWS へのマイグレーション。今回の新規小売が、移行先の AWS で本番を立ち上げる最初の小売になった(9 月 1 日にローンチ)
  • ネットスーパーとは独立したプロダクトとして開発しているピッキングシステムに、注文単位でピッキングする新モードを追加する開発

 呼び出しの向きは「ネットスーパー本体 → Server API → ピッキングサーバー」を基本とし、逆向きは EventBridge 経由の非同期に限っています。1 つの機能が 4〜5 リポジトリにまたがる構成です。

開発体制と Claude Code の使い方

項目 内容
人数 最小 3 名〜最大 7 名(7 名体制は 1 か月間のみ)。インフラは専任を置かず全員が兼務
設計書 product-docs(Markdown の設計書を GitHub で管理するリポジトリ。名前は本記事での仮のもの)
デザイナー 不在(Figma などのデザインツールも不使用)
Claude Code VSCode 拡張版やターミナル経由。プラグインは superpowers を中心に使用

 Claude Code は次のように設定して使いました。

  • メインのセッションは、その時点の最新の上位モデルに、Effort を Extra high にして実行していた。サブエージェントのモデルは特に指定せず、Claude Code の既定に任せました
  • 2026 年 8 月 14 日に Auto モードが default 化されてからは、常に Auto モードでプロンプトを実行してました。それに合わせて、以前の記事で紹介した Hooks による macOS の通知は、Claude Code から質問が来たとき(AskUserQuestion)だけ鳴るように変えました
  • それまでは「Edit automatically」で使いながら、.claude/settings.json の許可ルールを都度追加して整えてきたので、Auto モードに切り替えることに不安はありませんでした

 ツールの構成や挙動は、2026 年 6〜8 月時点のものです。

設計時のぶん回し

設計書を Fix してから実装する

 今回いちばん効果があったのは、いきなりコードを書かせないことでした。複数リポジトリにまたがる機能をそのまま実装させると、仕様の解釈違いや影響範囲の見落としが後から大量に出てきます。そこで、superpowers のワークフローに乗せて、次の流れで進めました。

① 要件と関連資料を渡し、「まず既存の仕組みを調べて」と頼む
② brainstorming:コード調査 → 質問 → 選択肢の比較 → 人間が方針を選ぶ
③ 設計書(DD)を書かせ、チームがレビューして Fix する
④ writing-plans:実装計画(PD)を実装リポジトリごとに作らせ、人間がレビューする
⑤ TDD とタスクごとのセルフレビューで実装させる
⑥ 実装で判明した差分を DD / PD に書き戻させる

 これはいわゆる仕様駆動開発(SDD)です。Birgitta Böckeler 氏の整理でいえば、実装後も設計書に書き戻して保守に使い続ける「spec-anchored」に近い運用です。superpowers の基本のワークフローとの違いは、設計書を専用のリポジトリに置いてチームでレビューすることと、実装計画を実装リポジトリごとに分けることです。

 AI に任せるのは「調べる・書く」まで、「決める」のは人間と線を引き、設計書も実装計画も、人間が OK を出すまで次の工程に進ませないことがポイントです。

依頼は「まず既存の仕組みを調べて」から始める

 「こう作ってほしい」と実装方法から指示するより、先に既存の仕組みを調べさせるほうが、既存の作りに沿った無理のない案が出てきます。

選択肢は「影響範囲」の列を立てて比べる

 方針を決める前に、2〜3 個の選択肢を「処理の流れ・メリット・デメリット」付きで並べさせ、人間が選びます。判断が割れる論点は、ここでチームでも議論します。比較の表には変更対象の影響範囲の列も必ず立てさせています。「1 行追加するだけ」の改修案が、実は 10 以上の小売が共有し、毎日使われる画面への変更だったことがあるためです。

決まったことは設計書の冒頭とメモリに残す

 議論で決まったことがチャットに埋もれていると、AI は次のセッションでそれを参照できません。設計書の冒頭に「決定事項サマリー」を置き、レビューで受けた指摘は「なぜ」と「次からどうするか」を付けて Claude Code のメモリに残すようにしました。こうしておくと、一度受けた指摘を毎回言い直す必要がなくなります。

アーキテクチャ選定で分かったこと

 EventBridge を使った 2 つの仕組み(バッチの起動と、ピッキングサーバーからの非同期連携)は、どちらも選択肢を並べて比べ、採用しなかった案とその理由を設計書に残しました。その中で分かったことは次の 3 つです。

  • 前提から見直す。NAT Gateway の新設コストを避けるためにバッチのタスクをパブリックサブネットに置いていたが、見直すと NAT Gateway はすでにあり、そもそもの前提が成り立っていなかった
  • 非同期にするなら、受け手の作法まで決める。EventBridge の API Destination は応答待ちが最大 5 秒で、リトライするのは 5xx と一部の 4xx だけなので、受け手は 5 秒以内に返し、再送しても直らないエラーはリトライされない 4xx で返す。同じイベントが何度届いても結果が変わらないようにもしておく
  • 非同期がいつも正解ではない。ECS のタスク起動に 1 分ほどかかるため、データ連携の開始時に台帳を作る処理は同期にした

参考: Amazon EventBridge のターゲットとしての API 送信先(AWS ドキュメント。5 秒のタイムアウトと、リトライするエラーコードの一覧が載っています)

画面設計:Excel と MD3 の方針だけで作る

 デザイナーもデザインツールも使わず、インプットは既存アプリのスクショに要望の吹き出しを描き足した Excel と、Material Design 3(MD3)に沿うという方針だけでした。Claude Code は画像を読めるので、スクショを渡して画面ごとの設計書に MD3 のコンポーネント、色・文字のトークン、アクセシビリティまで書かせ、アプリ開発エンジニアがレビューしてから実装しました。これでデザイナー不在でも「エンジニアのなんとなくの見た目」にはなりませんでしたが、現場の作業者が使う業務アプリだから成立した面も大きいです。

実装時のぶん回し

実装計画も、人間がレビューしてから走らせる

 設計書が Fix したら、superpowers の writing-plans で実装計画 plan.md を作ります。作業を 1 ステップ 2〜5 分の単位に分け、ファイル・コード・テストの方法まで書く方針なので、1 本で 2,000 行を超えることも珍しくありません。plan.md も人間がレビューしてから実装に入ります。plan.md を書き終えるなり実装を始めさせると、ファイルの数・タスクの順序・コミットの粒度といった、こちらの方針が反映されるべき判断を後から直すコストが大きいためです。

テストは先に書かせ、失敗を見てから実装させる

 AI は「テストが通るように実装を寄せる」だけでなく「実装に合わせてテストを直す」こともやりがちなので、先に失敗を見る手順を挟むことが大事でした。

OpenAPI と design.md で「正」を分ける

 API の型は OpenAPI、業務的な意味は design.md を正とすることで、サーバー担当とアプリ担当が I/F だけ先に合意し、それぞれ Claude Code で並行して実装を進められました。

インフラ:検証環境と本番環境の差異に苦戦

 移行先の AWS の本番環境は、Claude Code に 今春既に構築済の検証環境 の Terraform を棚卸しさせ、「検証環境 のこのリソース → 本番ではこの名前・この設定で作る」という対応表を設計書にまとめさせたうえで、約 2 週間で構築しました。この期間は単に AWS リソース作成だけで終わらず、セキュリティ対策や、本番環境特有の WEB アプリケーション側の設定ファイルの設定差異を調整する作業も行いました。

新本番環境想定の負荷試験をやり切る

 ローンチ前の負荷試験を1人でやり切る必要があったためここでも Claude Code をぶん回してやり切りました。

  • 目標値は推定ではなく、既存の小売の実アクセスや過去の注文実績データから出しました
  • 環境はコスト節約のため AWS リソース適用を 3 モードで切り替えられるように工夫しました
  • ワーカーの枯渇を見逃さないように外部依存のモックには本番相当の遅延を入れるようにしました
  • ボトルネックは Web ではなく DB という結果が得られ、インデックスの欠落を見つけることができました

アプリ動作確認:エミュレータを Claude Code に操作させる

 画面をまたぐ動作確認は、Claude Code に Android エミュレータを adb で操作させ、スクリーンショットを見て判定させました。弊社のテックブログには、iOS シミュレータと Maestro MCP で試した記事もありますが、今回は MCP を使わず素の adb です。カメラを使う画面も、エミュレータの仮想カメラに QR コードの画像を貼れば、実機なしで確認できます。一方で、ラベルプリンターやハンディ端末のスキャナーはエミュレータでは確かめられないので、widget テストと人間による実機確認で補いました。

参考: カメラのサポート(Android Studio のドキュメント。エミュレータの仮想シーンに QR コードなどの画像を取り込む手順が載っています)

実装計画に「セルフフィードバック」を組み込む

 実装計画の最後には、ブランチ全体の差分をレビューさせるタスクを毎回入れ、設計書に書かれていない実装上の判断(失敗時の扱い、エッジケース、競合、再試行など)を重点的に見させています。人間のレビュー指摘が、設計書で合意済みの部分よりもこうした隙間に集中していたためです。このレビューで、「何も選ばずに適用すると全件分の重いリクエストが飛ぶ」という設計書自体の欠陥が見つかったこともあります。

Crashlytics の調査

 Firebase MCP 経由で Crashlytics の FATAL を調べさせると、別々に見えていた 3 件(計 69 events)が、1 つの共通実装に起因していることを突き止めてくれました。

問題点と解決策

 ここからが本題です。3 か月ぶん回してみて実際に困ったことと、取り入れた解決策を組にして紹介します。

問題点 1:尤もらしい誤りと「存在しない懸念」が紛れ込む

 AI が書いた設計書は体裁が整っていて、それらしく読めてしまいます。しかし実際のレビューでは、指摘に対して次のような回答が返ってくる場面がありました。

AI が出したけど誤っていた内容で、削除するのを失念していました。

AI が「こういう問題が起こりうる」と言い始めて、調査したらそんなものはなかった、というやり取りがあって、そのやり取りをわざわざ設計書に入れてしまっていました。

 インフラでも、EFS のバーストクレジットが尽きるまでの日数や、試験環境の維持費(月 $3〜20 → 実測で月 $1.3)の見積もりが、尤もらしい根拠付きで大きく外れていました。AI は「知らないこと」を知らないとは言わず、それらしく埋めてしまいます。そして書いた本人も AI の出力をすべて把握しきれていないまま PR が出てしまう、というのが一番怖いところでした。

解決策 1:「推測」と「確定」を分けて書かせ、出典を残させる

  • 「事実と推測を区別する」をメモリにフィードバックとして残す
  • 設計書には「未確定事項」の節を設け、確定していないことはそこに書かせる
  • 決定事項には、いつ・どの議論で決まったかを添えさせる
  • AI との試行錯誤の経緯は設計書に書かせない。残すのは決まった結論と、その理由だけ
  • 業務の事実確認が必要な設計書は、業務を知るメンバーを必ず Reviewer に入れる

 加えて、PR を出す前に「この設計書の内容を、自分の言葉で説明できるか」を作成者自身が確かめることが大事だと感じています。

問題点 2:AI は足し算が得意で、引き算が苦手

 AI に設計させると、「この問題も起こりうるので対策を」「ここも新規 API を作って」と、機能と考慮事項がどんどん足されていきます。たとえばパッキング完了の書き戻しの設計書は、当初「受け取った側がピッキング結果を取りに行く API を新設する」案でした。これにレビューで「本当に新規で実装する必要がある? EventBridge にリクエストボディで渡せばいいのでは?」と指摘が入り、API を新設せずに済みました。AI は、工数や運用コストの観点で「そもそも作らなくていいのでは」と引き算をするのが苦手で、このレビューはまさにそれを人間が担った例です。

解決策 2:方針とスコープで「引き算」を仕組みにする

  • 「無改修で済む案があるなら、既存コードを直す案より優先する」をメモリに残し、AI の提案の前提にする
  • 判断が割れる論点は、設計書より先に選択肢比較を作らせ、影響範囲の列を見て人間が決める
  • 設計書の冒頭には「スコープに含むもの」だけでなく「スコープ外」を必ず書かせ、外した論点は「残課題」の節に担当者付きで残す

 「やらないこと」を書く場所をあらかじめ用意しておくと、AI が出してきた懸念を「今回はやらない、でも忘れない」と判断しやすくなります。

問題点 3:ドキュメントが膨大になり、人間のレビューが追いつかない

 AI は疲れないので、設計書も計画書もいくらでも書けます。設計書の PR は 3 か月で 68 本とリポジトリ別で最も多く、注文別ピッキング関連だけで設計書・計画書は約 3.6 万行、最も大きい設計書 PR は 1 本で追加 12,670 行に達しました。

 人間がこれをすべて精読するのは現実的ではなく、「書いてあるけど誰もちゃんと読んでいない」箇所から、問題点 1 の誤りがそのまま素通りしてしまいます。SDD のツールを試した Böckeler 氏も「正直、これだけの Markdown ファイルより、コードをレビューしたい」と書いています。SDD はレビューのボトルネックを消すのではなく、コードから設計書という上流へ移すのだと思います。

解決策 3:人間が読むもの(design.md)と AI が読むもの(plan.md)を分ける

ドキュメント 主な読み手 内容 人間のレビュー
design.md(設計書) 人間 何を・なぜ作るか(業務的な意味、方針、影響範囲、未確定事項、非ゴール) しっかり読んで議論する
plan.md(実装計画) AI どうやって作るか(ファイル単位のタスク、テスト、コマンド) ファイルの数・タスクの順序・コミットの粒度を中心に見て、OK を出してから実装させる

 人間のレビュー力は有限なので、精読の優先度を design.md に置き、設計書は画面・サブ機能の単位に分けて 1 本の PR を小さく保つようにしました。実際、レビューの往復が多かった設計書 PR の上位 4 本は、いずれも追加 400 行未満でした。

 PR が大量にある中、具体的なレビュー方針は次のように分けて実施しました。

  • 設計書(DD):議論すべき論点や、Fix が必要な未決事項を残した状態があっても PR を出し、チームで決めきりました
  • 実装(編集量が大量ではないもの):今まで通りの担当 Reviewer が全て目視でレビューをしました
  • 実装(編集量が多いもの):AI にレビューさせました。ただし任せきりにはせず、結果をまず Reviewer へ報告させ、その内容を吟味し AI と会話して、コメント投稿させていました

問題点 4:設計書と実装がズレていく

 実装して初めてわかる仕様の穴は、必ず出てきます。実装中に Claude Code が「計画にない判断」をして解決すると、それが設計書に戻ってきません。設計書が古くなると、次の設計で AI がその古い前提を読み込んでしまい、設計駆動の良さが逆に仇になります。

解決策 4:実装結果を設計書に書き戻すまでを「完了」にする

  • 実装が終わったら「計画からの逸脱」「実装で判明した仕様の穴」「残課題」を設計書に追記するところまでを、タスクの完了条件にする
  • 設計書の冒頭にステータス欄(「実装済(日付)」「未リリース」など)を置き、実装のたびに更新させる

 なぜ計画から外れたかを一番よく知っているのは、実装したセッションの Claude Code なので、その場で書き戻させるのが最も安上がりです。

問題点 5:リポジトリやセッションをまたぐと前提が抜け落ちる

 1 つの機能が複数のリポジトリにまたがるため、A リポジトリで決めたことを B リポジトリの Claude Code セッションは知りません。長い作業では、途中でコンテキストが要約されて細部も落ちます。開発人数が増減する中で、人間同士の引き継ぎに加えて「AI への引き継ぎ」も必要になりました。

解決策 5:共有メモリと HANDOFF.md でコンテキストを受け渡す

置き場所 スコープ 内容
CLAUDE.md 全リポジトリ共通 / リポジトリ 前提・ルール(Git の禁止事項、ドキュメントの置き場所など)
メモリ リポジトリ レビューで受けた指摘や過去の経緯(「なぜ」と「次からどうするか」付き)
共有メモリ リポジトリ横断 他リポジトリに影響する変更(API 仕様、DB スキーマ、インフラ、設計判断など)
HANDOFF.md 作業単位 セッションをまたぐ作業の引き継ぎ(いまの状態、次にやること、引き継ぎプロンプト)

 共有メモリには、作業完了時に Skill で「影響先」「内容」「対応が必要なこと」を書き出させ、関連リポジトリで作業を始めるときに読ませています。「設計書のここがまだ実装に追従していない」といった申し送りも書かせているので、問題点 4 のズレを追跡する台帳にもなります。セッションの残りが少なくなったら、その時点の状態を HANDOFF.md に書き出させてから新しいセッションに切り替えると、細部が落ちる前に区切れます。

問題点 6:Git 操作で作業ツリーやブランチ運用を壊しかける

 AI は良かれと思って Git を操作します。設計書リポジトリの master への直接コミット、並行して動いていた別セッションの作業とぶつかるブランチの切り替え、別ブランチの中身を読むつもりの git checkout <ref> -- . で未コミットの 27 件のローカル変更を消す、ということが実際に起きました。未ステージの変更は Git では復元できず、残る手段は VS Code のローカル履歴だけです。

解決策 6:Git のルールを CLAUDE.md に明文化し、コミットは人間が握る

## 作業ツリーを壊さない(全リポジトリ共通・最優先)

- 調査・参照のためにユーザの作業ツリーへ書き込むコマンドを実行しない。
  他コミットの内容は `git show <ref>:<path>` / `git worktree add` などで読む。
- 明示的な GO なしに実行禁止: `git checkout -- .` / `git restore` / `git reset --hard` / `git clean` / `git switch` / `git stash`
- 作業ツリーには常に未コミット変更がある前提で動く。触れる前に `git status` を見る。

 あわせて、git add -A は使わない、設計書リポジトリのコミット・push・PR は人間が行う、並行作業は git worktree add でディレクトリを分ける、といったルールもメモリに残しています。「AI を信用しない」のではなく、AI が壊せない構造にしておくのがポイントです。

まとめ

 3 か月間、少人数のチームで、Server API・インフラ・Android のピッキングアプリまでを、Claude Code をぶん回して開発しました。デザイナー不在でアプリを作り切れたのも、AWS へのマイグレーションと並走しながら本番構築から負荷試験までを回せたのも、AI 駆動ならではだと思います。

 一方で、ぶん回してみてわかったのは、AI 駆動開発のボトルネックは AI ではなく、人間のレビューと意思決定だということです。AI は調べて書くのは圧倒的に速いですが、尤もらしい誤りを混ぜ、足し算で膨らみ、決めたことを忘れ、ときには Git を壊します。だからこそ、

  • 設計書を先に書き、人間がレビュー・議論して Fix してから実装する
  • 人間が読むもの(design.md)と AI が読むもの(plan.md)を分ける
  • 決めたこと・やらないこと・ズレたことを、AI が読める Markdown に残し続ける
  • AI が壊せない構造(ルールと worktree)を先に作っておく

という「人間が決めて、AI がぶん回す」ための仕組みづくりが、結果的に一番の生産性向上につながりました。

問題提起について

 AI に任せきっても、それほど悪くないものができあがってしまうくらい、モデルの精度は高くなっています。その中で、エンジニアの存在意義や能力の差が平準化されるのでは、という危惧もあります。ただ、今回の開発プロジェクトを振り返ると、私は決してそうではないと実感しています。理由は 2 つあります。

  • AI と設計を詰め切る力は、一朝一夕では身につかない:superpowers の brainstorming で AI と質疑を繰り返し、設計を Fix させる力は簡単には身につかないと思います。その議論をチームで行うときの発言力や決定力にも、差が大きく出てくるはずです
  • AI を疑うには、ドメインとシステムの理解が要る:ハルシネーションによる誤りや、尤もらしい実装が出てきたときに、まず疑ってセルフフィードバックできることが重要です。そのためには、プロジェクトのユビキタス言語やドメイン知識、システム全体の仕様をしっかり理解しておかなければなりません

誰が読んでも共通のドメイン知識を持てる product-docs

 設計書を置いている product-docs は、AI、エンジニア、問い合わせ担当、セールス担当の 4 種の読み手に役立つことを目指したリポジトリです。Markdown を中心に、Claude Code を通して誰が読んでも共通のドメイン知識を持てるよう、都度書きためています。書き足しているのはエンジニアなので設計書が多いですが、既存システムの仕様やアーキテクチャをまとめたドキュメントもあります。

 このリポジトリをチームでフル活用したおかげで、ドメイン駆動での開発が円滑に進み、スポットで参加したエンジニア 3 名にもパフォーマンスを発揮してアウトカムを出してもらえたのではないかと感じています。

最後に

エブリーでは、ともに働く仲間を募集しています。

テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください!

corp.every.tv

Unity CatalogのData ClassificationとABACで機密データを検出しマスクする

はじめに

こんにちは
開発本部 開発1部 デリッシュリサーチでデータエンジニアをしている吉田です。

今回は、Unity CatalogのData Classificationで機密データを含むカラムを自動検出し、付与されたタグをもとにABACのcolumn maskポリシーでマスクするまでを試した話を紹介します。

注意点

本記事の検証に使ったデータは、すべてFakerで生成した架空のものです。
実在する人物や実環境のデータは含んでいません。

また、テストに使ったテーブルは検証用に新しく作ったものです。
Data Classificationはインテリジェントなスキャンでコストを最適化とあるため、本番のテーブルでは異なる間隔でスキャンされる可能性があります。

背景

データ基盤には、メールアドレスや氏名のような機密データを含むカラムが含まれる可能性があります。
分析に使う人が増えるほど、こうしたカラムは必要な人以外にはマスクして見せる必要があります。
しかし、カラムごとに人手で対応する運用には以下のような辛さがあります。

  • テーブルが増えるたびに、機密データを含むカラムを人が探して印を付ける必要がある
  • カラム名から中身が判断できないカラム(自由記述のメモなど)で抜けが出る
  • 印を付けたあと、カラムごとにマスクの設定を追従させる必要がある

そこで、機密データの検出とタグ付けを自動化するData Classificationと、タグをもとにマスクを一括適用できるABACを組み合わせ、人手を介さずにマスクまで処理できるかを調査しました。

Data Classificationとは

Data Classificationは、カタログ配下のテーブルをAIエージェントがスキャンし、機密データを含むカラムを検出してgoverned tagを付ける機能です。

https://docs.databricks.com/aws/ja/data-governance/unity-catalog/data-classification

  • カタログ単位で有効化し、スキャン対象のスキーマを絞れる
  • 検出できるクラスはclass.email_addressやclass.nameなど90種類以上ある
    • 日本向けにはclass.jp_my_number(マイナンバー)とclass.jp_pension_number(基礎年金番号)がある
  • 検出結果はsystemテーブルsystem.data_classification.resultsにカラム単位で保存される
    • 確信度(HIGH / LOW)と一致したレコードの割合を持つ
  • 新しいテーブルやカラムは、通常は作成から24時間以内にスキャンされる
    • 自動タグ付けを有効にする前に検出済みだったカラムは次のスキャンでタグが付き、それ以降の検出には検出と同時にタグが付く
    • 既存テーブルへのデータ追加がいつスキャンされるかは、ドキュメントに記述がない
  • スキャンにはサーバレスコンピュートが必要

ABACのcolumn maskポリシーとは

ABAC(属性ベースのアクセス制御)は、governed tagを条件にしてrow filterやcolumn maskをカタログ、スキーマ、テーブルの単位で一括適用する仕組みです。

https://docs.databricks.com/aws/ja/data-governance/unity-catalog/abac/

ポリシーはCREATE POLICYで作成し、MATCH COLUMNS has_tag('class.email_address')のようにタグで対象カラムを指定します。
カラムごとにマスク関数を紐付ける方式と違い、ポリシーを一度書けばあとから追加されたテーブルにも適用されます。
Data Classificationが付けるclass.*のタグをそのまま条件に書けるため、検出からマスクまでを自動化できます。

検証の流れ

検証は以下の流れで行いました。

  • 合成データ
    • Fakerで生成した架空の会員データやイベントログを、検証専用のカタログにテーブルとして作成する
  • ABACポリシー
    • カラムの型ごとにマスク関数とポリシーを用意し、手動でタグを付けたカラムで動作を確認する
  • Data Classification
    • カタログで有効化してスキャンし、自動で付いたタグでポリシーが有効になることを確認する

ドキュメント上はスキャンまで最大24時間かかるため、ポリシー側を先に手動タグで確認する順序にしています。

テストデータ

以下の9テーブルを合成データで作成しました。
値はすべてFaker('ja_JP')とseed固定の乱数で生成しています。

テーブル レコード数 確かめたいこと 値の例
users_plain 200 漢字やかなの氏名、全角の電話番号、都道府県から始まる住所、郵便番号といった日本語の値が検出されるか。ハッシュ化済みのメールカラムを誤検出しないか name: 坂本 加奈、name_kana: タナカ ユウタ、phone_zenkaku: 090-0000-3891、address: 千葉県葛飾区芝浦14丁目11番7号、email_sha256: a56e5ab9…(64桁)
events_nested 200 mapやstruct、arrayの中に入った機密データが検出されるか event_params: {"email": {"string_value": "rikamatsumoto@example.com", "int_value": null}, "count": {"string_value": null, "int_value": 3}}、geo: {"country": "日本", "region": "神奈川県", "city": "横浜市磯子区"}、items: [{"item_name": "サラダ", "buyer_email": "rikamatsumoto@example.com"}]
misleading 200 カラム名がemailで中身が整数のカラムや、自由記述のメモに紛れたメールアドレスをどう扱うか email: 142188、memo: 配送先変更の依頼。担当は鈴木 拓真さん、連絡先pyamada@example.org。
users_sparse 500 8割がNULLのカラムでも検出されるか email: 8割がNULL、birthday: 6割がNULLで、0001-01-01のような異常値も5%混ぜる
jp_numbers 200 マイナンバーと基礎年金番号の形式が日本向けクラスとして検出されるか。マイナンバーは検査用数字の正誤で検出が変わるか my_number_valid: 207016914928、my_number_invalid: 検査用数字だけ誤った12桁、my_number_mixed: 半分のレコードだけ検査用数字が正しい12桁、pension_number: 2419-269849
ratio_test 1,000 メールの混入率が1レコード、1%、5%、20%と低いカラムでも検出されるか one_in_1000: 999レコードが「問題なし」などの普通の文字列で1レコードだけメール、one_in_1000_null: 999レコードがNULLで1レコードだけメール
late_arrival 100 有効化したあとに追加したテーブルがいつスキャンされるか。スキャン後にカラムを追加した場合に再評価されるか contact_email、full_name、あとから追加したphone
ratio_1m 1,000,000 実データ相当のレコード数で、混入率が1レコード、10レコード、0.01%、0.1%、1%のカラムが検出されるか p0001: 100レコードだけuser979104@example.comのようなメールで、残りは普通の文字列
names_test 200 氏名の表記(よくある名前の「姓 名」、スペース無し、姓のみ、名のみ、ローマ字、「様」付き、ひらがな)で検出が変わるか common_space: 山田 舞、romaji: Mikako Sasaki、hiragana: はせがわ まあや

メールアドレスはexample.comなどの予約ドメイン、電話番号は090-0000-XXXXの帯に固定し、実在の値と衝突しないようにしています。
Fakerは項目ごとに独立した値を返すため、かなは漢字の氏名と対応せず、住所も実在しない都道府県と市区町村の組み合わせになります。

マスク関数とポリシーの作成

まず、users_plainのemailとbirthday、jp_numbersのmy_number_validに手動でタグを付け、ポリシーの動作を確認します。

ALTER TABLE poc_data_classification.synthetic.users_plain
ALTER COLUMN email SET TAGS ('class.email_address');

ポリシーが呼ぶ関数の引数の型は、対象カラムの型と一致している必要があるため、マスク関数はカラムの型ごとに用意します。

CREATE OR REPLACE FUNCTION poc_data_classification.synthetic.mask_string(v STRING)
RETURNS STRING
RETURN CASE
  WHEN v IS NULL THEN NULL
  WHEN v LIKE '%@%' THEN concat('***@', substring_index(v, '@', -1))
  ELSE '***'
END;

ポリシーもクラスごとではなく、カラムの型ごとに作ります。
同じユーザーの同じカラムに異なるcolumn maskが解決されると、クエリがエラーになるためです。
同じ関数を同じ引数で呼ぶポリシーであれば、クラスごとに分けて1つのカラムに複数当たっても問題なく読めました。
それでも、自由記述のカラムには複数のクラスが付くことがあるので、STRINGカラム向けのクラスはORでまとめて1本のポリシーにし、1つのカラムに解決されるマスクを常に1つにしています。

CREATE OR REPLACE POLICY mask_pii_string
ON SCHEMA poc_data_classification.synthetic
COLUMN MASK poc_data_classification.synthetic.mask_string
TO `account users`
FOR TABLES
MATCH COLUMNS (
  has_tag('class.email_address')
  OR has_tag('class.phone_number')
  OR has_tag('class.name')
  OR has_tag('class.location')
  OR has_tag('class.jp_my_number')
  OR has_tag('class.jp_pension_number')
) AS c
ON COLUMN c;

マスクの確認

ポリシーを作成した直後にSELECTすると、反映を待つことなくタグを付けたカラムだけがマスクされていました。

user_id | email           | name      | birthday
--------+-----------------+-----------+-----------
1       | ***@example.net | 坂本 加奈 | 1900-01-01
2       | ***@example.com | 石井 陽一 | 1900-01-01

ポリシーはスキーマの所有者である自分自身にも適用されます。
次に、同じポリシーにEXCEPTで管理者グループを追加しました。

TO `account users` EXCEPT `unity-catalog-admins`

置き換えた直後に同じクエリを流すと、管理者グループに属する自分には素の値が返りました。

user_id | email                       | name      | birthday
--------+-----------------------------+-----------+-----------
1       | miturukobayashi@example.net | 坂本 加奈 | 1981-04-27
2       | naoto63@example.com         | 石井 陽一 | 1972-05-31

なお、column maskポリシーが付いたテーブルではタイムトラベルが使えません。
VERSION AS OFを指定したクエリは以下のエラーになりました。

[COLUMN_MASKS_FEATURE_NOT_SUPPORTED.TIME_TRAVEL] Column mask policies for `poc_data_classification`.`synthetic`.`users_plain` are not supported: Time travel for table with column mask policies

Data Classificationの有効化

手動で付けたタグを外し、Data Classificationを有効化します。
有効化はUIのほか、Databricks CLIのdata-classificationコマンドからも行えます。
スキャン対象を検証用のスキーマだけに絞るため、CLIでincluded_schemasを指定しました。

databricks data-classification create-catalog-config catalogs/poc_data_classification \
  --json '{"included_schemas": {"names": ["synthetic"]}}'

有効化しただけでは検出は行われますが、タグは自動では付きません。
返ってきた設定のauto_tag_configsは空で、自動タグ付けはクラスごとに有効にする必要がありました。

databricks data-classification update-catalog-config \
  catalogs/poc_data_classification/config auto_tag_configs \
  --json '{"auto_tag_configs": [
    {"classification_tag": "class.email_address", "auto_tagging_mode": "AUTO_TAGGING_ENABLED"},
    ...
  ]}'

このとき、governed tagとして存在するclass.*のキーをすべて指定すると、class.de_passportが検出クラスとして見つからないというエラーになりました。
governed tagのキーの一覧と検出できるクラスの一覧は一致しないため、サポートされる分類タグをもとに指定する必要があります。

検出結果

検出されたカラムと検出されなかった主なカラムを、確かめたかったことごとに整理します。
確信度はすべてHIGHでした。

確かめたかったこと カラム 検出クラス 一致率 結果
メールアドレス email class.email_address 1.0 検出
漢字の氏名 name class.name 0.04 検出
カナの氏名 name_kana class.name 0.27 検出
電話番号(ハイフン、数字のみ、全角) phone_* class.phone_number 1.0 3カラムとも検出
郵便番号、都道府県から始まる住所 postcode, address class.location なし, 0.04 検出
生年月日 birthday class.date_of_birth なし 検出
メールアドレスのSHA-256 email_sha256 なし 検出されず
マスク済みメール(m***@example.net) email_masked class.email_address 1.0 検出。3回目のスキャンでは検出されず
カラム名がemailで中身が整数 misleading.email なし 検出されず
自由記述のメモ misleading.memo class.email_address, class.phone_number, class.name 0.35, 0.33, 0.08 3クラスを検出
mapの中のメール event_params class.email_address 1.0 検出
structの中の地域名 geo class.location なし 検出
array<struct>の中のメール items class.email_address 1.0 検出
8割がNULLのメール users_sparse.email class.email_address 0.19 検出
1,000レコード中1レコードだけメール(残りは普通の文字列) ratio_test.one_in_1000 class.email_address 0.001 検出
1,000レコード中1レコードだけメール(残りはNULL) ratio_test.one_in_1000_null class.email_address 0.001 検出
1,000レコード中1%、5%、20%がメール ratio_test.p01, p05, p20 class.email_address 0.01, 0.05, 0.2 検出
100万レコード中1%、0.1%、0.01%がメール ratio_1m.p01, p001, p0001 class.email_address 0.0103, 0.0012, 0.0001 検出。0.01%のカラムは3回目のスキャンでは検出されず
100万レコード中10レコード、1レコードだけメール ratio_1m.ten_in_1m, one_in_1m なし 検出されず
よくある名前だけの「姓 名」 names_test.common_space class.name なし 検出
姓名(スペース無し)、姓のみ、名のみ、「様」付き、ひらがな names_test.* class.name 0.15, 0.01, なし, 0.02, 0.17 すべて検出
ローマ字の氏名 names_test.romaji class.name 0.99 検出
スキャン後に追加したカラムの電話番号 late_arrival.phone class.phone_number 1.0 次の日次スキャンで検出
スキャン後に追加したレコードのメール misleading.status, jp_numbers.memo class.email_address 0.5, 0.33 日次スキャンでは再評価されず、フルスキャンで検出
マイナンバー(検査用数字がすべて正しい) jp_numbers.my_number_valid class.jp_my_number 1.0 検出
マイナンバー(半分だけ正しい) jp_numbers.my_number_mixed class.jp_my_number 0.5 検出
マイナンバー(すべて誤り) jp_numbers.my_number_invalid なし 検出されず
基礎年金番号形式 pension_number class.jp_pension_number 1.0 検出

会員IDや整数のコードカラム、作成日時には何も付きませんでした。

  • タグはネストの中のフィールドではなく、カラムそのものに付きます
  • 100万レコードの表では、全レコードではなくサンプルを見ています
    • 一致率が混入率からずれ、100レコードのカラムで保存されたサンプル値は1件でした。1万レコード前後を抜き出して判定していそうです
  • 検出結果はスキャンのたびに変わることがあります
    • 100万レコード中0.01%のカラムとマスク済みメールのカラムは、3回のスキャンのうち最後のフルスキャンで検出から外れました
    • 検出から外れても、付いていたタグはそのまま残りました。誤検出で付いたタグも自然には消えず、外すのは人の作業になりそうです
  • 一致率は、見たレコードのうち検出器が一致したレコードの割合です
    • 日付やstructのカラム、よくある名前だけの氏名のカラムではNULLでした
  • マイナンバーは、検査用数字(12桁目のチェックデジット)まで検証されています
    • 検査用数字が正しいカラムは一致率1.0、半分だけ正しいカラムは0.5で、すべて誤ったカラムは検出されませんでした
    • 12桁の数字が並んでいるだけではclass.jp_my_numberは付きません

自動タグでのマスクの確認

自動で付いたタグで、もう一度各テーブルをSELECTしました。
users_plainでは検出された10カラムがすべてマスクされ、ハッシュカラムだけが残りました。
misleading.memoには3つのクラスが付きましたが、STRINGカラム向けのクラスを1本にまとめてあるので衝突せずにマスクされました。

一方、events_nestedは、タグの付いていないカラムだけを選ぶクエリでも失敗しました。

[DATATYPE_MISMATCH.CAST_WITHOUT_SUGGESTION] Cannot resolve "poc_data_classification.synthetic.mask_string(event_params)" due to data type mismatch: cannot cast "STRING" to "MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>>".

スキーマに置いたSTRING向けのポリシーがmap型のevent_paramsにも解決され、関数の引数の型が合わないためです。
クエリで選んだカラムに関係なく、テーブル全体が読めなくなります。
MATCH COLUMNSに使える関数はカラムのタグを見るhas_tagとhas_tag_valueだけで、カラムの型で振り分ける方法はありません。

そこで、ネスト型のカラムを持つテーブルを別扱いにしました。

  • 振り分け用のgoverned tag(abac_profile)を作り、events_nestedに付ける
  • スキーマのポリシーにWHEN NOT has_tag('abac_profile')を足し、このテーブルを除外する
  • events_nestedにはテーブル単位で、map型とstruct型それぞれに合う関数のポリシーをつける
CREATE OR REPLACE FUNCTION poc_data_classification.synthetic.mask_params(
  v MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>>
)
RETURNS MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>>
RETURN transform_values(
  v,
  (k, x) -> named_struct(
    'string_value', CASE WHEN x.string_value IS NULL THEN NULL ELSE '***' END,
    'int_value', x.int_value
  )
);

CREATE OR REPLACE POLICY mask_nested_params
ON TABLE poc_data_classification.synthetic.events_nested
COLUMN MASK poc_data_classification.synthetic.mask_params
TO `account users`
FOR TABLES
MATCH COLUMNS has_tag('class.email_address') AS c
ON COLUMN c;

この構成でevents_nestedが読めるようになり、event_paramsの文字列値とgeoの地域名がマスクされました。

event_params: {"email":{"string_value":"***","int_value":null},"page":{"string_value":"***","int_value":null},"count":{"string_value":null,"int_value":"3"}}
geo: {"country":"日本","region":"***","city":"***"}

ただし、items(array<struct>)のカラムは守れませんでした。
このカラムにもclass.email_addressが付いていましたが、map型のevent_paramsと同じクラスなので、テーブル単位のポリシーでも1本の関数では型が合いません。
同じクラスのタグが型の違うカラムに付いたテーブルでは、そのクラスのポリシーを書けないことになります。
今回はitemsのタグを外しましたが、本番でこの形のテーブルを扱うなら、ネストを展開したテーブルを別に用意するか、テーブルごとアクセスを絞る必要がありそうです。

なお、スキーマのポリシーとテーブルのポリシーが同じカラムに重なった状態では以下のエラーになります。
どのポリシーがどのカラムで衝突したかがすべて書かれているため、切り分けは容易でした。

[COLUMN_MASKS_FEATURE_NOT_SUPPORTED.MULTIPLE_MASKS] ... resulting in multiple column masks mask_params(event_params), mask_string(event_params), mask_geo(geo), mask_string(geo) applying to the same column(s) event_params, geo.

現時点での使用感

日本語の氏名や住所、電話番号などは問題なく検出され、ネストした型の中身まで見てカラムにタグが付きました。
大きなテーブルではサンプルで判定するため、混入がごく少ないカラムは見落とされ、スキャンのたびに結果が変わることもありますが、それ以外で検出の精度に困る場面はありませんでした。

しかし、テーブルやカラム、レコード追加後からスキャンされるまでの間に一定の時間がかかるため、機密データが一定期間露出する可能性があります。
そのため任意タイミングでの再スキャンを行いたいですが、現時点ではUI経由でしか実行できないため、運用上の不便な点となります。

コストに関してはサーバレスジョブコンピュートのDBUがかかります。
Data Classification固有のコストはありませんが、スキャン対象のテーブル数やレコード量によってコストが増加すると思います。

まとめ

Data Classificationで検出したカラムにABACのポリシーが適用されるところまで、人手でタグを付けることなく処理できました。
日本語の値やネストした型の中身も検出され、大きなテーブルでのサンプリングによる見落としを除けば、検出の精度で困ることはなさそうだと感じました。
設計で考えることが残るのはポリシーの側で、カラムの型ごとにポリシーを分けることと、ネスト型のカラムを持つテーブルの扱いが必要になります。

Claude Code のハーネス、別リポジトリからコピペしてない?

title banner

「あ、こっちのリポジトリにスキル移植してなかったや。コピペしとこ」

Claude Code を使っていると、一度はやりますよね。
新規リポジトリに新しくハーネスを移植する際など、あまり良くないとわかりつつ、やりがちだと思います。

そこで今回は、株式会社エブリー Android チームでの、ハーネスの一元管理方法について紹介します。
3 つのリポジトリでコピペしていたハーネスを 1 か所にまとめ、合計 5,254 行の設定を消せました。
「本当は一元管理した方が良いのだろうな」と思っているあなたの一助になれば幸いです。

1. まとめ

  • コピーした設定はズレていくため、一元管理した方が良い。
  • リポジトリごとの技術スタックの違いにより、単純な共通化は難しい
  • リポジトリごとの大きな技術スタックの違いは、読み込む plugin を変えることで対応する
  • リポジトリごとの小さな技術スタックの違いは、読み込むファイルを変えることで対応する

2. 背景

エブリーには、ネイティブで開発されている Android アプリが 4 つあります。
私がメインで触っているのは、そのうち 2 つと、Kotlin で書いた社内ツールの計 3 つのリポジトリです。

  1. delishkitchen.tv
  2. www.docomo.ne.jp
  3. Issue を拾って Claude Code に PR まで作らせる、JVM で動く社内ツール

同じ Kotlin ですが、前提条件は違います。

デリッシュキッチン dバリューパス版 社内 JVM ツール
動く場所 Android Android JVM(Android SDK なし)
UI XML View + Compose Android View なし
DI Hilt + Koin 古い版の Koin のみ なし
ビルド定義 Kotlin DSL + 共通ビルドロジック Groovy Kotlin DSL(単一モジュール)
version catalog あり なし なし
ktlint CI と hook で実行 なし CI で実行

この 3 つに、とりあえずハーネスをコピペしました。しばらく経ったころ、資産がいちばん多いデリッシュキッチンと社内ツールで、同じ名前のファイル 5 本を比べてみました。

同名のファイル デリッシュキッチン 社内ツール 差分(追加 + 削除)
実装手順のコマンド 591 行 530 行 977 行
セキュリティのレビュー用エージェント 151 行 72 行 202 行
Kotlin のレビュー用エージェント 113 行 77 行 174 行
コメントの点検スキル 81 行 75 行 141 行
レビューのコマンド 66 行 77 行 138 行
合計 1,632 行

行数が近いファイルでも差分は大きくて、両側でそれぞれ勝手に書き換わっていました。
ただ重複しているだけなら一方を消すだけで良いのですが、実際はリポジトリごとに中身が分岐していました。
従って修正のたびに、どちらが正しいのか・どこまで反映すべきなのかを判断する必要が出てきました。

3. 原因

ではコピーした設定は、なぜ書き換わっていくのでしょう。 結論から述べると、各々がよりハーネスを効かせようとした結果、コード分岐が進むためです。 あるリポジトリに対して一番よくハーネスを効かせようとすると、具体的な記述が必要になります。

例)

  • Manifest の exported を見る
  • Compose の再コンポジションを見る

実際、Kotlin のレビュー用エージェントを比べると、デリッシュキッチン版には Compose の観点が、社内ツール版には「依存を増やさない」「JVM 17 で動く」など固有の観点が足されていました。

これらのファイルは当然、技術スタックが違うので流用ができません。
従って、各々がよりハーネスを効かせようとした結果コード分岐が進んでいくのです。

4. 解決策

DRY 原則に従い 1 つにまとめて配れば良いと、解決策として真っ先に思いつきます。これは正しいと思います。
ただ原因に記載した通り、技術スタックが違うので流用できないという問題を解決する必要があります。下記は私が考えた解決策です。

案 結果
共通部分のみ一元管理し、それ以外は各リポジトリ固有で持つ [不採用] 管理が簡単だが、共有できる箇所が少なく根本的な解決にならないため
技術の組み合わせごとに配布物を分ける [不採用] Compose 用のスキル、Hilt 用のスキル……と組み合わせの数だけ作成するため、配布物が膨大になる
共通の部分を層にして配り、技術ごとの知識はリポジトリの設定を見て読み分ける [採用] 具体的な記述は技術ごとに分けて持ち、どれを読むかをリポジトリごとに選ぶ

配布に関しては Claude Code の plugin を前提としました。
採用案は、大きな関心ごとは plugin を多層で分けます。その層の中で、詳細に関しては capability gate という仕組み (後述) を用いて選択させます。

切り替え 何で決めるか 例
層 どのリポジトリが使うか Kotlin 共通 / Android 共通 / ドメイン固有
capability gate そのリポジトリがどの技術を使うか Compose / Android View / ktlint の有無

4.1 層

どのリポジトリが使うかで、層を分けます。今回の場合、層は Kotlin 共通・Android 共通・ドメイン固有の 3 つで分けました。依存の向きは「ドメイン → Android 共通 → Kotlin 共通」です。

層の境界は、次の基準だけで決めました。

そのハーネスは、その層を使うリポジトリのうち、使っている技術がいちばん少ないところでも成り立つか。

層 前提にしてよい 前提にしてはいけない
Kotlin 共通 Kotlin、Gradle、git / GitHub など Android SDK、宣言的 UI、DI コンテナ、ビルド定義の書き方、特定の lint ツール
Android 共通 Android、Activity / Fragment / ViewModel など 宣言的 UI、特定の DI、ビルド定義の書き方、version catalog
ドメイン固有 自社ドメインの業務知識 あらゆる技術スタック

これで Android SDK の採用など、大きな技術スタックの違いによる分岐に対応できます。
例えば Android SDK 非採用の JVM ツールでは、Kotlin 共通の plugin を入れることで、Android 関連のコンテキストを無視できます。
それだけでなく、JVM ツール開発時に行ったハーネス修正の知見が、より深い層の plugin を使用するリポジトリにも適用されるのです。

しかしながら、これだけではまだ分岐に対応しきれていません。
例えば Android の UI に関してのレビュースキルを考えます。リポジトリ A では Compose を、リポジトリ B では XML View を使用している場合、それぞれに同じスキルを使用すると、お互いにノイズとなるコンテキストが含まれてしまいます。
ここで 4.1 と同じやり方で Compose 層、XML View 層と分けてしまうと、層の数が膨大になり、依存関係が複雑になることで、管理が難しくなります。

この層の中の違いを吸収するのが、次の capability gate です。

4.2 Capability gate

capability gate は、層の中の違いを吸収し、分岐に対応する仕組みです。
使用ライブラリなどの細かい技術スタック (capabilities) を、リポジトリ内に設定ファイルとして持ちます。
実行時にこの設定ファイルを読み、AI コンテキストを切り替えます。

ディレクトリ構成は、下記を想定しています。
技術ごとの観点を SKILL.md 本文から丸ごと抜き出し、reference/ 配下へ保存します。
SKILL.md の description に分岐条件を記載することで、コンテキストの分岐に対応します。

ui-layer-review/
├── SKILL.md          # 手順と、技術に依らない観点だけ
└── reference/
    ├── compose.md    # Compose の観点
    └── xml-view.md   # XML View の観点

設定は独自の json ファイルとして保持しています。

{
  "capabilities": {
    "platform": "android",
    "ui": ["xml"],
    "di": ["koin"],
    "versionCatalog": false,
    "lint": []
  }
}

capabilities.ui の値と、読む reference の対応は次のとおりです。

capabilities.ui 読む reference 振る舞い
"ui": ["compose"] compose.md Compose の観点でレビューする
"ui": ["xml"] xml-view.md XML View の観点でレビューする
"ui": ["compose", "xml"] 両方 差分のファイル種別で当てる観点を決める
"ui": [] なし UI レイヤーを持たないリポジトリとして指摘せずに終わる
未設定 なし 未適用としてレビューする

5. 結果

リポジトリ + -
デリッシュキッチン 183 2,129
dバリューパス版 446 2,032
社内 JVM ツール 255 1,093
合計 884 5,254

足した行に関しては、主に下記です。

  • CLAUDE.md の修正
  • 技術スタックの設定ファイル追加
  • リポジトリ固有ハーネスの書き直し

そのリポジトリ固有の情報を持つファイル以外は、一元管理できるようになりました。

これで Claude Code のハーネスを別リポジトリからコピペすることは、ほぼ起きなくなったと思います。

6. おわりに

エブリーの Android チームでは、今後 Loop Engineering を導入し、真剣にプロダクト開発速度 10 倍を目指しています。
このハーネスの一元管理化も、その一環として行いました。ハーネス自身の開発速度も向上したため、一歩前進です。
道のりはまだまだ、大変厳しいと思いますが、高い目標に向けて駆け抜けたいと思います。


この記事は、株式会社エブリーで Android アプリ開発を担当している岡田が書きました。

エブリーでは、ともに働く仲間を募集しています。

corp.every.tv

Agent 向けのリアーキテクチャは誰の開発効率を上げたのか

はじめに

デリッシュキッチン開発部でデジタルサイネージの開発を行っている鈴木(@0muji4_eng)です。

昨今、Cursor や Claude Code などの自律型 LLM Agent を活用した開発が一般的になりつつあります。「Agent に気持ちよくコードを書かせるためには、アーキテクチャを Agent 向けに整える必要がある」という言説も、見かけるようになりました。

私も、広告配信の案件管理を行うための巨大な React アプリを AI 駆動開発に最適化すべく、全面的なリアーキテクチャを実施しました。

しかし、同一タスクを LLM Agent に実行させて前後で定量的に計測したところ、Agent のパフォーマンスが上がるという当初の仮説は外れていました。

本記事では、計測の結果わかったことをお話しします。

1. 課題とリアーキテクチャの決意

対象となったのは、React + Vite + TanStack Router で構築されたアプリケーションです。

移行前のコードベースには、大きく 2 つの課題がありました。

  1. 関心事の分散(レイヤーベースの弊害)

    施策・マスタ・認証といった異なるビジネスドメインのコードが、種類別(components/, lib/, constants/)に同じ階層に配置され、混在していました。

  2. ルートファイルの肥大化(Fat Routes)

    例えば、複雑なデータ入力を行う入稿画面は 1,404 行、各種設定が並ぶ広告詳細画面は 1,546 行に達しており、1 つのルートファイルの中に zod スキーマ、変換関数、テーブル定義、複数のセクションコンポーネントが同居していました。

私は、「この散らばった構造と巨大なファイルは、きっと Agent のコンテキストを圧迫し、パフォーマンスを低下させているはずだ」という仮説を立て、抜本的なリアーキテクチャを決意しました。

2. LLM Agent を意識したアーキテクチャへの刷新

Feature-Based(機能別)と Vertical Slice(垂直スライス)の考え方をベースにしつつ、LLM Agent のために、以下の 3 つの厳格なルールを設けたアーキテクチャへ移行しました。

  • 機能別のコロケーション: src/features/ 配下に、UI からロジック、定数までをすべて集約する。
  • 単方向依存とバレルファイル禁止: routes → features → 共有層という依存方向を徹底する。index.ts(バレルファイル)による再エクスポートは禁止し、import は常に具体的なファイルまでのパスで書く。
  • ルートの薄化: src/routes/ のファイルには Route 定義とページの骨格だけを残し、中身は機能層へ移す。

実際に構築した、リアーキテクチャ後のディレクトリツリー(抜粋)がこちらです。

src/
├── api/                  # orval生成物(不可侵領域)
├── components/           # 全体共有UI(shadcn 等・特定のドメインを持たない)
├── features/             # ビジネスドメインの隔離領域
│   ├── auth/
│   ├── campaigns/        # 巨大ドメインも 1 つにまとめる
│   │   ├── components/
│   │   │   ├── ad-detail/      # 画面(関心事)ごとにサブフォルダ化
│   │   │   ├── campaign-detail/
│   │   │   ├── delivery/
│   │   │   └── entry/
│   │   ├── constants/
│   │   ├── hooks/
│   │   └── lib/
│   └── masters/
└── routes/               # URLの物理位置(中身は骨格のみ)
    ├── _auth/
    │   ├── campaigns.$campaignId.tsx
    │   ├── campaigns.entry.tsx
    │   └── ...
    └── __root.tsx

ルーティングから重いロジックが消え、Agent はキャンペーンの機能を修正したいとき、features/campaigns/ の中だけを探索すればよい状態になりました。

では、このアーキテクチャ刷新によって何が起きたのか。定量的な計測結果を見ていきます。

3. 実測結果

リアーキテクチャの前と後のスナップショットを用意し、同一機能のまま定量的な計測を行いました。人間のための指標と LLM Agent のための指標という 2 つの観点から結果を見ていきます。

3.1 改善した人間のための指標

まずは、アーキテクチャの局所性を示す指標です。

  • ルートファイルの行数: −70〜92%

    • 施策詳細画面: 894 行 → 89 行(−90%)
    • 広告詳細画面: 1,546 行 → 125 行(−92%)
    • 配信設定画面: 1,128 行 → 132 行(−88%)
    • 入稿画面: 1,404 行 → 417 行(−70%)
  • タスクあたりの読み込み対象(変更の局所性): −62%

    • 「入稿フォームの必須チェックを変える」などの 5 タスクを実行する際に、参照すべき対象シンボルを含むファイル量を計測。
    • 5 タスク合計で 271.6KB → 104.1KB(−62%)。

これにより、人間がコードを読み、レビューするときに見る範囲は大きく減りました。変更の影響範囲が機能ごとのディレクトリに収まり、レビューする人は該当するファイルだけを見ればよくなったからです。

3.2 仮説が外れた LLM Agent のための指標

ここからが本題です。「Agent が迷わないアーキテクチャにしたのだから、さぞ Agent の一発成功率も上がっただろう」と期待して計測を行いました。

しかし、計測結果は仮説どおりにはなりませんでした。

LLM Agent の一発成功率: 【差なし】

まず、同じタスク 3 つを、経緯を知らない新しい Agent(Claude Code / Claude Sonnet 5.5)に、前後のスナップショットそれぞれで 1 回ずつ実行させました。その中には、表に列を追加するタスクも入れました。見出しだけを追加して各行のセルを追加し忘れると表が崩れる、間違えやすいタスクです。手順は毎回「実装 → ビルド 1 回 → 報告して終了、追加の修正は禁止」に固定し、頼んだ範囲だけを変更してビルドが通れば合格としました。

結果はリアーキテクチャ前が 2/3、後が 3/3 で、前の巨大ファイル側で 1 回だけ不合格になりました。入稿画面の入力項目の 1 つを必須チェックから外すよう頼んだところ、頼んでいない送信処理まで書き換え、型の必須項目を as CaseBody のキャストで回避していました。ビルドは通りますが、レビューでは差し戻しになる変更です。

1 回ずつの試行では、これがリアーキテクチャ前のコードのせいなのか、たまたまなのかを区別できません。そこで、このタスクだけを Claude Sonnet 5.5・Claude Opus 5.5・Claude Fable 5 の 3 モデルで、前後それぞれ 5 回ずつ実行し直しました(計 30 回)。

モデル リアーキテクチャ前 リアーキテクチャ後
Sonnet 5.5 2/5 3/5
Opus 5.5 5/5 4/5
Fable 5 5/5 5/5
合計 12/15 12/15

合計は前後とも 12/15 で、リアーキテクチャによる差は見られませんでした。消費トークンも、どのモデルでも前後で目立った差はありません。

不合格は 6 回あり、どれも頼んでいない送信処理(toCaseBody)を同じように書き換えていました。リアーキテクチャ前の 1,404 行のルートファイルでも、リアーキテクチャで切り出した 182 行のファイルでも、同じ書き換えが起きていました。不合格の数が分かれたのはリアーキテクチャの前後ではなくモデルで、Sonnet 5.5 が 10 回中 5 回、Opus 5.5 が 1 回、Fable 5 が 0 回です。

Agent は、ファイルを頭から読むのではなく、関数名や変数名で検索して該当箇所を見つけてから読みます。そのため、1,546 行の巨大ファイルでも、間違えやすいタスクでも、毎回、目的の箇所にたどり着けていました。

4. 結論:今回のリアーキテクチャの効果

アーキテクチャを整えても、Agent の一発成功率は上がりませんでした。今回の計測では、LLM Agent は 1,500 行規模の巨大ファイルでも、検索で目的の箇所にたどり着けていました。

では、今回のリアーキテクチャは無駄だったのでしょうか。決してそうではありません。

結論として、今回のリアーキテクチャに LLM Agent の成功率を上げる効果はありませんでした。今回の結果から、機能別のアーキテクチャの価値は、人間のレビューコストを下げ、複数 Agent の並行作業(コンフリクト回避)を可能にし、依存のルール違反を自動で見つけられるようにするための基盤にあると考えています。

  1. 人間のレビューを成立させる:

    実測データ(参照ファイル量 −62%)が示す通り、Agent が短時間で書いた大量のコードを、人間が無理なく読める範囲に収めます。

  2. 複数 Agent の並行作業を成立させる:

    1,546 行の Fat Route のままでは、複数の LLM Agent(あるいは人間)に同時に別々のタスクを依頼すると、同じファイルを編集することになり、Git のコンフリクトが起きやすくなります。機能ごと、画面ごとにファイルを分けたことで、Agent に並行して実装させやすくなりました。

  3. 依存関係のルール違反を自動で見つけられるようにする:

    routes → features → 共有層という依存関係をルールとして決めたことで、feature 同士の import のようなルール違反を grep で自動的に見つけられるようになりました。ルールが守られているかを、レビューする人の目に頼らずに確かめられます。

LLM Agent が短時間で大量のコードを書く時代だからこそ、人間が把握できる局所性の高いアーキテクチャが、これまで以上に求められている。それが、今回の実測結果から得られた最大の学びです。